[React+Recoil] Recoil でつくるお天気アプリ
React の状態管理といえば Redux がまだまだ使われることが多そうですが、2020 年の 5 月に発表された状態管理ライブラリの Recoil を使ってシンプルなお天気アプリを作ってみました。
完成品は上記のリポジトリにあります。
今回つくるアプリについて
- セレクトボックスで都市を選択する、選択できる都市の情報はローカルで持たせる
- セレクトボックスの初期値は「選択なし」である
- 決定ボタンを押すとセレクトボックスで選択された都市の天気情報を表示させる
- API には OpenWeatherMap を使用する
- ローディング中には loading と表示させる
- エラー時には error と表示させる
上記のアプリを CRA で作成していくことを想定しています。
動作は上記の通りです(最終的にリロードボタンとデバッグボタンが追加でつきます)。
App.tsx
の最終形
App.tsx
の最終形は以下の通りです。
// App.tsx import React, { Suspense } from "react"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { WeatherForm } from "./components/WeatherForm"; import { WeatherResult } from "./components/WeatherResult"; export const App: React.VFC = () => { return ( <> <h1>お天気アプリ</h1> <WeatherForm /> <ErrorBoundary fallback={<p>error</p>}> <Suspense fallback={<p>loading</p>}> <WeatherResult /> </Suspense> </ErrorBoundary> </> ); };
1 つずつ見ていきましょう。
ユーザに選択される都市データと atom の作成
まずはコンポーネントを作成する前に都市のデータを記述していきます。city ID については Current weather dataの該当項目から調べることができます。
// utils/cities.ts export type CityId = typeof cities[number]["id"]; export const cities = [ { id: "2128295", name: "札幌" }, { id: "1850147", name: "東京" }, { id: "1853908", name: "大阪" }, { id: "1863958", name: "福岡" }, ] as const;
次にユーザの選択した都市がどこであるかの状態を保持する atom を作成していきます。
今回ディレクトリ構成には Recoil Patterns: Hierarchic & Separation を参考にさせていただいています。
// states/rootStates/cityId.ts export const cityIdState = atom<CityId | undefined>({ key: "CityId", default: undefined, });
接頭尾にある State
は最初つけていなかったのですが local state の cityId がでてきたときに困るという問題が発生したため付けています。ここでは公式のチュートリアルに合わせて接頭尾に State
を付けています。
この atom を使う場合には、下記のように何をするかで使用する hooks の使い分けることができます。
- 値を読み取るだけでいい->
useRecoilValue
- 値を set するだけでいい ->
useSetRecoilState
- どちらもする必要がある ->
useRecoilState
コンポーネント WeatherForm
の作成
実際のフォームのコンポーネントを見ていきましょう。このコンポーネントがやっていることは 2 点です。
- セレクトボックスが変更されたら、local state の
cityId
を変更する - submit を押したら、atom の値を現在選択されている local state の
cityId
に変更する
// components/WeatherForm.tsx import React, { useState } from "react"; import { useSetRecoilState } from "recoil"; import { cityIdState } from "../states/rootStates/cityId"; import { cities, CityId } from "../utils/city"; export const WeatherForm: React.VFC = () => { const [cityId, setCityId] = useState<CityId>(); const setStateCityId = useSetRecoilState(cityIdState); const changeCity = (e: React.ChangeEvent<HTMLSelectElement>) => { const id = e.currentTarget.value as CityId; setCityId(id); }; const submit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); setStateCityId(cityId); }; return ( <form onSubmit={submit}> <select onChange={changeCity}> <option value="">選択なし</option> {cities.map((city) => ( <option value={city.id} key={city.id}> {city.name} </option> ))} </select> <button type="submit">submit</button> </form> ); };
このコンポーネントでは atom の値を読み取る必要はないため useSetRecoilState
を使っています。
OpenWeatherMap からデータを取得する selector の作成
API へのリクエストなどの非同期処理をするには selector の get を async にする、あるいは Promise を戻すだけです。
// states/rootStates/weather.ts import { selector } from "recoil"; import { CurrentWeather } from "../../models/currentWeather"; import { cityIdState } from "./cityId"; export const weatherState = selector({ key: "Weather", get: async ({ get }) => { const cityId = get(cityIdState); if (!cityId) { return; } const res = await fetch(`https://...`); const json = await res.json(); return json; }, });
この Promise が解決されるまでは React Suspense を使ってレンダーを待機させ、ローディングなどを出すことができます。
また selector では、エラーを握りつぶさないように注意が必要です。これについては後述します。
コンポーネント WeatherResult
の作成
このコンポーネントは実際に API から取得してきたデータを表示するものです。
weather
が early return をしている理由は、初期表示、あるいは都市を選択しないで submit したケースを考慮しています。
// components/WeatherResult.ts // 表示データを一部省略しています import React from "react"; import { useRecoilValue } from "recoil"; import { weatherState } from "../states/rootStates/weather"; export const WeatherResult: React.VFC = () => { const weather = useRecoilValue(weatherState); if (!weather) { return null; } return ( <section> <h2>{weather.name}の天気</h2> <div>気温: {weather.main.temp} 度</div> </section> ); };
このコンポーネントが API の呼び出し中にローディングを表示するためには Suspense
で wrap します。
<Suspense fallback={<p>loading</p>}> <WeatherResult /> </Suspense>
エラー処理
selector
でスローされたエラーは React の Error Boundary でキャッチします。先ほど書いたようにうっかりエラーを握り潰してしまうと、エラーをキャッチすることができません(私は最初やってしまい、しばらく原因がわからずハマりました……)。
// components/ErrorBoundary.tsx import React from "react"; type ErrorBoundaryProps = { fallback: React.ReactNode; }; type ErrorBoundaryState = { hasError: boolean; error?: Error; }; export class ErrorBoundary extends React.Component< ErrorBoundaryProps, ErrorBoundaryState > { state = { hasError: false, error: undefined }; static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error, }; } render(): React.ReactNode { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } }
Error Boundary は現時点(2021 年 2 月)で hooks に対応していないため、Class Component である必要があります。こちらのコードは公式のサンプルコードに型をつけただけのものです。
リロードができない問題と解決策
このアプリは一度取得したデータを再取得して更新ができません。
この挙動で問題にならない場合もありますが、今回は時間によって変化する天気の情報なのでリロードが可能であるとより親切です。
recoil で非同期処理の refresh したい場合には少し工夫が必要で、Query Refresh としてその方法が公式ドキュメントに記載されています。
下記の PR がリロード可能な状態になったものです。
再度リクエストを投げるトリガーとして、atomFamily
で weatherRequestIdState
を作成。weatherState
は cityId
を渡せる selectorFamily
に変更し動的に生成し、weatherRequestIdState
を取得しています。この状態で weatherRequestIdState
のカウントアップさせると refresh できる、という仕組みのようです。
refresh が目的だとわからなければ、なぜこういったコードになっているのか把握するのがなかなか難しそうだと感じました。
デバッグについて
公式の上記のページに記載されているページは現時点(2021 年 2 月)でそのままだと動かないため、一部修正が必要になります。
getNodes
->getNodes_UNSTABLE
modified
->isModified
ただし、やはりデバッグの機能としては少しつらく、atom
や selector
だけであればそこまででもありません。
ですが atomFamily
や selectorFamily
になってくると難しいのではないかなと感じました。